package edu.hawaii.myisern.model;

import static java.util.Collections.emptyMap;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import edu.hawaii.myisern.collaborations.jaxb.Collaboration;
import edu.hawaii.myisern.collaborations.jaxb.Collaborations;
import edu.hawaii.myisern.organizations.jaxb.AffiliatedResearchers;
import edu.hawaii.myisern.organizations.jaxb.Organization;
import edu.hawaii.myisern.organizations.jaxb.Organizations;
import edu.hawaii.myisern.researchers.jaxb.Researcher;
import edu.hawaii.myisern.researchers.jaxb.Researchers;

/**
 * Data Model for the MyIsern program.
 * 
 * Provides access to researchers, organizations, and collaborations. Also includes methods for
 * loading data from files and checking for dangling references between entities.
 * 
 * @author Brian Jaress
 * @auther Kevin English
 * 
 */
public class IsernModel {

  // containers for the data
  private Set<Id> ids;
  private Map<Id, Researcher> researchers;
  private Map<Id, Organization> organizations;
  private Map<Id, Collaboration> collaborations;

  // files to load and save from
  private String researchersFile;
  private String organizationsFile;
  private String collaborationsFile;
  private String path;

  /**
   * Initializes a new, empty model.
   */
  public IsernModel() {
    this.ids = new HashSet<Id>();
    this.researchers = new HashMap<Id, Researcher>();
    this.organizations = new HashMap<Id, Organization>();
    this.collaborations = new HashMap<Id, Collaboration>();
  }

  /**
   * Load data from files.
   * 
   * @throws JAXBException if it cannot create a parser
   * @throws IOException on bad input
   */
  public synchronized void load() throws JAXBException, IOException {
    this.ids.clear();

    if (fileExists(this.researchersFile)) {
      loadResearchers();
    } else {
      saveResearchers();
    }
    if (fileExists(this.organizationsFile) ) { 
      loadOrganizations();  
    } else {
      saveOrganizations();
    }
    if (fileExists(this.collaborationsFile) ) {
      loadCollaborations();  
    } else {
      saveCollaborations();
    }

    for (Organization organization : this.organizations.values()) {
      this.fillAffiliatedResearchers(organization);
    }
    //this.complete();
    save();
  }

  /**
   * Load Collaboration data from it's xml file.
   * 
   * @throws JAXBException if it cannot create a parser
   * @throws IOException on bad input
   */
  private void loadCollaborations() throws JAXBException, IOException {
    this.collaborations.clear();
    for (Collaboration c : ((Collaborations) JAXBContext.newInstance(Collaborations.class)
        .createUnmarshaller().unmarshal(
            new File(
                this.path + File.separatorChar + this.collaborationsFile))).getCollaboration()) {
      addCollaboration(c);
    }
  }
  
  /**
   * Load Organization data from it's xml  file.
   * 
   * @throws JAXBException if it cannot create a parser
   * @throws IOException on bad input
   */
  private void loadOrganizations() throws JAXBException, IOException {
    this.organizations.clear();
    for (Organization o : ((Organizations) JAXBContext.newInstance(Organizations.class)
        .createUnmarshaller().unmarshal(
            new File(
                this.path + File.separatorChar + this.organizationsFile))).getOrganization()) {
      addOrganization(o);
    }
  }
  
  /**
   * Load Researcher data from it's xml  file.
   * 
   * @throws JAXBException if it cannot create a parser
   * @throws IOException on bad input
   */
  private void loadResearchers() throws JAXBException, IOException {
    this.researchers.clear();
    for (Researcher r : ((Researchers) JAXBContext.newInstance(Researchers.class)
        .createUnmarshaller().unmarshal(
            new File(this.path + File.separatorChar + this.researchersFile))).getResearcher()) {
      addResearcher(r);
    }
  }
  
  /**
   * Complete all dangling references and sync both sides of double-sided relationships.
   * 
   * Organization records trump researcher records, so that if the UH record says that Phil Johnson
   * is affiliated, but the Phil Johnson record says his organization if UCXZ, Johnson ends up in
   * UH.
   */
  /*
  private void complete() {

    // collab reference to organization
    for (Collaboration c : this.collaborations.values()) {
      if (c.getCollaboratingOrganizations() == null) {
        continue;
      }
      for (String name : c.getCollaboratingOrganizations().getCollaboratingOrganization()) {

        // force org to exist
        if (!this.organizations.containsKey(new Id(name))) {
          Organization o = new Organization();
          o.setName(name);
          this.organizations.put(new Id(name), o);
        }
      }
    }
    
    // org reference to researcher
    for (Organization o : this.organizations.values()) {
      if (o.getAffiliatedResearchers() == null) {
        continue;
      }
      for (String name : o.getAffiliatedResearchers().getAffiliatedResearcher()) {
        Researcher r;
        Id resId = new Id(name);

        // force researcher to exist
        if (this.researchers.containsKey(resId)) {
          r = this.researchers.get(resId);
        }
        else {
          r = new Researcher();
          r.setName(name);
          this.researchers.put(resId, r);
        }

        // force researcher into org
        r.setOrganization(o.getName());
      }
    }

    // researcher reference to organization
    for (Researcher r : this.researchers.values()) {
      String name = r.getOrganization();
      Id orgId = new Id(name);
      Organization o;
      // force org to exist
      if (this.organizations.containsKey(orgId)) {
        o = this.organizations.get(orgId);
      }
      else {
        o = new Organization();
        o.setName(name);
        o.setAffiliatedResearchers(new AffiliatedResearchers());
        this.organizations.put(orgId, o);
      }
      // force researcher into org
      if (!o.getAffiliatedResearchers().getAffiliatedResearcher().contains(r.getName())) {
        o.getAffiliatedResearchers().getAffiliatedResearcher().add(r.getName());
      }
    }
  }
  */
  /**
   * 
   * @param path the path to set
   */
  public synchronized void setPath(String path) {
    this.path = path;
  }

  /**
   * Checks to see if a file exists in out application's data path. 
   * @param fileName Any filename.
   * @return True if the file exists in our working directory.
   */
  private boolean fileExists(String fileName) {
    return (new File(this.path + File.separatorChar + fileName)).exists() ;
  }
  
  /**
   * Return a Set of all IDs in use.
   * 
   * @return a Set of IDs
   */
  public synchronized Set<Id> getIds() {
    return this.ids;
  }

  /**
   * Return a map of current Researchers with IDs as keys.
   * 
   * @return a map of Researchers
   */
  public synchronized Map<Id, Researcher> getResearchers() {
    return this.researchers;
  }

  /**
   * Return a map of current Organizations with IDs as keys.
   * 
   * @return a map of Organizations
   */
  public synchronized Map<Id, Organization> getOrganizations() {
    return this.organizations;
  }

  /**
   * Return a map of current Collaborations with IDs as keys.
   * 
   * @return a map of Collaborations
   */
  public Map<Id, Collaboration> getCollaborations() {
    return this.collaborations;
  }

  /**
   * Check whether the Collaboration has an invalid year.
   * 
   * @param c the Collaboration
   * @return true if there is an invalid year
   */
  private static boolean hasBadYear(Collaboration c) {
    boolean bad = false;
    if (c.getYears() == null) {
      return false;
    }
    for (BigInteger year : c.getYears().getYear()) {
      if (year.longValue() > 2010 || year.longValue() < 1990) {
        bad = true;
      }
    }
    return bad;
  }

  /**
   * Save the data to files
   * 
   * @throws IOException on save error
   * @throws JAXBException if it cannot convert the objects to XML
   */
  public synchronized void save() throws IOException, JAXBException {

    // complete fixes references...
    // this.complete();
    saveResearchers();
    saveOrganizations();
    saveCollaborations();
  }
  /**
   * Save the researcher data to files
   * 
   * @throws IOException on save error
   * @throws JAXBException if it cannot convert the objects to XML
   */
  public synchronized void saveResearchers() throws IOException, JAXBException {
    Marshaller marshaller;
    marshaller = JAXBContext.newInstance(Researchers.class).createMarshaller();
    marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE);
    Researchers researchers = new Researchers();
    researchers.getResearcher().addAll(this.researchers.values());
    marshaller.marshal(researchers, new FileOutputStream(this.path + File.separatorChar
        + this.researchersFile));
  }

  /**
   * Save the organizations data to files
   * 
   * @throws IOException on save error
   * @throws JAXBException if it cannot convert the objects to XML
   */
  public synchronized void saveOrganizations() throws IOException, JAXBException {
    Marshaller marshaller;
    marshaller = JAXBContext.newInstance(Organizations.class).createMarshaller();
    marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE);
    Organizations organizations = new Organizations();
    organizations.getOrganization().addAll(this.organizations.values());
    marshaller.marshal(organizations, new FileOutputStream(this.path + File.separatorChar
        + this.organizationsFile));
  }


  /**
   * Save the Collaborations data to files.
   * @throws JAXBException on save error
   * @throws FileNotFoundException if it cannot convert the objects to XML
   */
  public synchronized void saveCollaborations() throws JAXBException, FileNotFoundException {
    Marshaller marshaller;
    marshaller = JAXBContext.newInstance(Collaborations.class).createMarshaller();
    marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE);
    Collaborations collaborations = new Collaborations();
    collaborations.getCollaboration().addAll(this.collaborations.values());
    marshaller.marshal(collaborations, new FileOutputStream(this.path + File.separatorChar
        + this.collaborationsFile));
  }

  /**
   * Add a collaboration.
   * 
   * @param collaboration the collaboration
   * @throws IOException if there is already another entity with the same ID
   */
  public synchronized void addCollaboration(Collaboration collaboration) throws IOException {
    Id id = new Id(collaboration.getName());
    if (this.ids.contains(id) || this.collaborations.containsKey(id) || hasBadYear(collaboration)
        || collaboration.getName().trim().equals("")) {
      throw new IOException("Duplicate ID: " + id.toString());
    }
    else {
      this.collaborations.put(id, collaboration);
      this.ids.add(id);
    }

  }

  /**
   * Add an organization.
   * 
   * @param organization the organization
   * @throws IOException if there is already another entity with the same ID
   */
  public synchronized void addOrganization(Organization organization) throws IOException {
    Id id = new Id(organization.getName());
    if (this.ids.contains(id) || this.organizations.containsKey(id)
        || organization.getName().trim().equals("")) {
      throw new IOException("Duplicate ID: " + id);
    }
    else {
      organizations.put(id, organization);
      this.ids.add(id);
    }
  }

  /**
   * Add researcher.
   * 
   * @param researcher the researcher
   * @throws IOException if there is already another entity with the same ID
   */
  public synchronized void addResearcher(Researcher researcher) throws IOException {
    Id id = new Id(researcher.getName());
    if (this.ids.contains(id) || 
        this.researchers.containsKey(id) || researcher.getName().trim().equals("")) {
      throw new IOException("Duplicate ID: " + id);
    }
    else {
      this.researchers.put(id, researcher);
      this.udpateResearcherToOrganization(researcher);
      this.ids.add(id);
    }
  }
  
  /**
   * Update collaboration with the given id, overwriting the data with data in the 
   * provided object..
   * 
   * @param id The Id for the given field.
   * @param collaboration The collaboration object.
   */
  public synchronized void updateCollaboration(Id id, Collaboration collaboration) {
    if (collaboration == null) {
      return;
    }
    Id newId = new Id(collaboration.getName());
    if (newId.toString().length() < 1) {
      return;
    }         
    if (!newId.equals(id)) {
      this.collaborations.remove(id);
      this.ids.remove(id);
    }
    this.collaborations.put(newId, collaboration);
    this.ids.add(newId);
  }

  /**
   * Updates the researcher with the given id, overwriting the data with data in the 
   * provided object.
   * 
   * @param id The Id for the given field.
   * @param researcher The researcher object.
   */
  public synchronized void updateResearcher(Id id, Researcher researcher) {
    if (researcher == null) {
      return;
    }
    Id newId = new Id(researcher.getName());
    if (newId.toString().length() < 1) {
      return;
    }        
    try {
      if (id != null) {
        this.remove(id);
      }
    }
    catch (IOException e) {
      e.printStackTrace();
    }
    this.ids.add(newId);
    this.researchers.put(newId, researcher);
    //renew record
    this.udpateResearcherToOrganization(researcher);
  }

  /**
   * Updates the organization with the given id, overwriting the data with data in the 
   * provided object.
   * 
   * @param id The Id for the given field.
   * @param organization The organization object with data to overwrite old organization.
   */
  public synchronized void updateOrganization(Id id, Organization organization) {
    if (organization == null) {
      return;
    }
    Id newId = new Id(organization.getName());
    if (newId.toString().length() < 1) {
      return;
    }        
    if (!newId.equals(id)) {
      this.organizations.remove(id);
      this.ids.remove(id);
    }
    this.organizations.put(newId, organization);
    this.ids.add(newId);
    this.fillAffiliatedResearchers(organization);
  }
  
  /**
   * Retrieve a list of researchers affiliated to the organization's . 
   * 
   * @param organization the organization. 
   * @return a list of researcher names. 
   */
  public synchronized List<String> retrieveAffiliatedResearchers(Organization organization) {
    List<String> affiliatedResearchers = new ArrayList<String>();
    if (organization != null) {
      String orgName = organization.getName();
      if (orgName != null) {
        for (Researcher researcher : this.researchers.values()) {
          if (orgName.equals(researcher.getOrganization()) ) {
            affiliatedResearchers.add(researcher.getName());
          }
        }
      }
    }
    return affiliatedResearchers;
  }

  /**
   * Fill the organization's affiliated researchers. 
   * 
   * @param organization the organization. 
   */
  public synchronized void fillAffiliatedResearchers(Organization organization) {
    if (organization != null) {
      if (organization.getAffiliatedResearchers() == null) {
        organization.setAffiliatedResearchers(new AffiliatedResearchers());
      }
      organization.getAffiliatedResearchers().getAffiliatedResearcher().clear();
      organization.getAffiliatedResearchers().getAffiliatedResearcher().
                                  addAll(this.retrieveAffiliatedResearchers(organization));
      organization.getAffiliatedResearchers().getAffiliatedResearcher().
                                  remove(organization.getContact());
    }
  }
  
  /**
   * Add the researcher to his organization's affiliated researcher list.
   * if his not the organization's contact. 
   * @param researcher the Researcher.
   */
  public synchronized void udpateResearcherToOrganization(Researcher researcher) {
    if (researcher != null) {
      Organization org = this.organizations.get(new Id(researcher.getOrganization()));
      if (org != null && researcher.getName() != null 
          && !researcher.getName().equals(org.getContact())) {
        if (org.getAffiliatedResearchers() == null) {
          org.setAffiliatedResearchers(new AffiliatedResearchers());
          org.getAffiliatedResearchers().getAffiliatedResearcher().add(researcher.getName());
        }
        else if (!org.getAffiliatedResearchers().getAffiliatedResearcher()
                .contains(researcher.getName())) {
          org.getAffiliatedResearchers().getAffiliatedResearcher().add(researcher.getName());
        }
      }
    }
  }
  
  /**
   * Remove a collaboration, organization or researcher, according to the id
   * 
   * @param id the given Id.
   * @throws IOException when matching object is not found.
   */
  public synchronized void remove(Id id) throws IOException {
    if (this.ids.contains(id)) {
      //=====remove collaboration======
      if (this.collaborations.containsKey(id)) {
        this.collaborations.remove(id);
      }
      //=====remove organization======
      else if (this.organizations.containsKey(id)) {
        String orgName = this.organizations.remove(id).getName();
        for (Collaboration collaboration : this.collaborations.values()) {
          if (collaboration != null && collaboration.getCollaboratingOrganizations() != null) {
            collaboration.getCollaboratingOrganizations().getCollaboratingOrganization()
              .remove(orgName);
          }
        }
      }
      //=====remove researcher======
      else if (this.researchers.containsKey(id)) {
        Researcher researcher = this.researchers.remove(id);
        Organization organization = this.organizations.get(new Id(researcher.getOrganization()));
        if (organization != null && organization.getContact() != null) {
          if (organization.getContact().equals(researcher.getName())) {
            organization.setContact("");
          }
          else if (organization.getAffiliatedResearchers() != null) {
            organization.getAffiliatedResearchers().getAffiliatedResearcher()
                .remove(researcher.getName());
          }
        }
      }
      else {
        this.ids.remove(id);
        throw new IOException("ID in Set not matched any object: " + id);
      }
      this.ids.remove(id);
    }
    else {
      throw new IOException("ID not found: " + id);
    }
  }
  
  /**
   * Create new researcher.
   * 
   * @param researcher The researcher object.
   */
  private synchronized void createResearcher(Researcher researcher) {
    updateResearcher(null, researcher);
  }

  /**
   * Comparisons for organization collaboration levels
   * 
   * @see #getOrganizationsByCollaborationLevel(CollabLevelCompare, int)
   */
  public enum CollabLevelCompare {
    /** Same as given level */
    EQUAL() {
      /**
       * Equality test
       * 
       * @param a left-hand side
       * @param b right-hand side
       * @return true if equal
       */
      boolean isMatch(int a, int b) {
        return a == b;
      }
    },

    /** Strictly greater than given level */
    GREATER() {
      /**
       * Greater-than test
       * 
       * @param a left-hand side
       * @param b right-hand side
       * @return true if strictly greater than
       */
      boolean isMatch(int a, int b) {
        return a > b;
      }
    };

    /**
     * Check for a match between an organization's collaboration level and a target level.
     * 
     * @param orgLevel the collaboration level of the organization
     * @param targetLevel the target level
     * @return true if there is a match
     */
    abstract boolean isMatch(int orgLevel, int targetLevel);
  }

  /**
   * Get organizations by collaborations level
   * 
   * @param comp the type of comparison that determines whether an organization's collaboration
   * level matches what we're looking for
   * @param target the target level
   * @return a map of matching organizations
   */
  public synchronized Map<Id, Organization> getOrganizationsByCollaborationLevel(
                                                    CollabLevelCompare comp, int target) {
    Map<Id, Integer> levels = new HashMap<Id, Integer>();

    // check the level of each org
    for (Collaboration c : this.collaborations.values()) {
      for (String name : c.getCollaboratingOrganizations().getCollaboratingOrganization()) {
        Id id = new Id(name);
        if (levels.containsKey(id)) {
          levels.put(id, 1 + levels.get(id));
        }
        else {
          levels.put(id, 1);
        }
      }
    }

    // find matches
    Map<Id, Organization> matches = new HashMap<Id, Organization>();
    for (Id id : this.organizations.keySet()) {
      if (comp.isMatch(levels.containsKey(id) ? levels.get(id) : 0, target)) {
        matches.put(id, this.organizations.get(id));
      }
    }

    return matches;
  }

  /**
   * Get collaborations by organization
   * 
   * @param orgId the organization to look for
   * @return a map of collaborations involving the organization
   */
  public synchronized Map<Id, Collaboration> getCollaborationsByOrganization(Id orgId) {
    Id id = orgId;
    if (id == null) {
      id = new Id();
    }
    return filterCollabs(CollabFilter.ORG_ID, id.toString());
  }

  /**
   * Get collaborations by year
   * 
   * @param year the year to look for
   * @return a map of collaborations that were happening in the given year
   */
  public synchronized Map<Id, Collaboration> getCollaborationsByYear(int year) {
    return filterCollabs(CollabFilter.YEAR, Integer.toString(year));
  }

  /**
   * Get collaborations by researcher
   * 
   * @param researcherId the ID of the researcher to look for
   * @return a map of collaborations involving the organization to which the researcher belongs.
   */
  public synchronized Map<Id, Collaboration> getCollaborationsByResearcher(Id researcherId) {
    if (this.researchers.containsKey(researcherId)) {
      return getCollaborationsByOrganization(new Id(this.researchers.get(researcherId)
          .getOrganization()));
    }
    else {
      return emptyMap();
    }
  }

  /**
   * Different ways of filtering the the Collaborations
   * 
   * This is just an internal helper class.
   */
  private enum CollabFilter {
    ORG_ID {
      /**
       * Check if a collaboration matches the target organization
       * 
       * @param c the collaboration
       * @param id the Id of target
       * @return true if it matches
       */
      boolean isMatch(Collaboration c, String id) {
        boolean match = false;
        if (c.getCollaboratingOrganizations() == null) {
          return false;
        }
        for (String name : c.getCollaboratingOrganizations().getCollaboratingOrganization()) {
          if (new Id(name).equals(new Id(id))) {
            match = true;
          }
        }
        return match;
      }
    },

    YEAR {
      /**
       * Check if a collaboration matches the target year
       * 
       * @param c the collaboration
       * @param target the target
       * @return true if it matches
       */
      boolean isMatch(Collaboration c, String target) {
        boolean match = false;
        if (c.getYears() == null) {
          return false;
        }
        for (BigInteger year : c.getYears().getYear()) {
          if (year.equals(new BigInteger(target))) {
            match = true;
          }
        }
        return match;
      }
    };

    /**
     * Check if a collaboration matches the target
     * 
     * @param c the collaboration
     * @param target the target
     * @return true if it matches
     */
    abstract boolean isMatch(Collaboration c, String target);
  }

  /**
   * Applies the given filter to create a map of matching Collaborations.
   * 
   * @param filter the filter
   * @param target the filter parameter
   * @return a map of all matching filters
   */
  private Map<Id, Collaboration> filterCollabs(CollabFilter filter, String target) {
    Map<Id, Collaboration> matches = new HashMap<Id, Collaboration>();

    for (Map.Entry<Id, Collaboration> entry : this.collaborations.entrySet()) {
      if (filter.isMatch(entry.getValue(), target)) {
        matches.put(entry.getKey(), entry.getValue());
      }
    }
    return matches;
  }

  /**
   * Gets a sorted list of the collaborations
   * 
   * @return a list of collaborations, sorted by name
   */
  public synchronized List<Collaboration> getCollaborationList() {
    return getDataList(this.collaborations);
  }

  /**
   * Gets a sorted list of the organizations
   * 
   * @return a list of organizations, sorted by name
   */
  public synchronized List<Organization> getOrganizationList() {
    return getDataList(this.organizations);
  }

  /**
   * Gets a sorted list of the researchers
   * 
   * @return a list of researchers, sorted by name
   */
  public synchronized List<Researcher> getResearcherList() {
    return getDataList(this.researchers);
  }

  /**
   * Returns a sorted list of the values in a Map with String keys.
   * 
   * @param <T> the type of the data
   * @param dataMap a map from strings to data
   * @return a list of the data, sorted by the string keys
   */
  private <T> List<T> getDataList(Map<Id, T> dataMap) {
    SortedSet<Id> ids = new TreeSet<Id>(dataMap.keySet());
    List<T> dataList = new ArrayList<T>();
    for (Id id : ids) {
      dataList.add(dataMap.get(id));
    }
    return dataList;
  }

  /**
   * @param researchersFile the researchersFile to set
   */
  public synchronized void setResearchersFile(String researchersFile) {
    this.researchersFile = researchersFile;
  }

  /**
   * @param organizationsFile the organizationsFile to set
   */
  public synchronized void setOrganizationsFile(String organizationsFile) {
    this.organizationsFile = organizationsFile;
  }

  /**
   * 
   * @param collaborationsFile the collaborationsFile to set
   */
  public synchronized void setCollaborationsFile(String collaborationsFile) {
    this.collaborationsFile = collaborationsFile;
  }
  
}
